﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;

namespace Ns
{
    /// <summary>
    /// Represents a <see cref="Recurrence"/> executed 
    /// every <see cref="DayOfMonth"/> 
    /// or every <see cref="WeekDaysOfMonth"/> 
    /// of specified <see cref="Months"/>
    /// at a specified <see cref="TimeOfDay"/>.
    /// This class cannot be inherited.
    /// </summary>
    [Serializable]
    [DataContract]
    public sealed class MonthlyRecurrence : Recurrence
    {
        #region Properties

        /// <summary>
        /// Gets the time of day.
        /// </summary>
        [DataMember(EmitDefaultValue = false)]
        public TimeSpan TimeOfDay { get; private set; }

        /// <summary>
        /// Gets the recurrent day of month (ex: every 23rd).
        /// </summary>
        [DataMember(EmitDefaultValue = false)]
        public int DayOfMonth { get; private set; }
        
        /// <summary>
        /// Gets the recurrent week days of month (ex: every second Tuesday &amp; Thursday of the month).
        /// </summary>
        [DataMember(EmitDefaultValue = false)]
        public RecurrentWeekDaysOfMonth WeekDaysOfMonth { get; private set; }
       
        /// <summary>
        /// Gets the recurrent months (ex: every January, April).
        /// </summary>
        [DataMember(EmitDefaultValue = false)]
        public RecurrentMonths Months { get; private set; }

        #endregion

        #region Methods

        /// <summary>
        /// Returns a <see cref="System.String"/> that represents this instance.
        /// </summary>
        /// <returns>
        /// A <see cref="System.String"/> that represents this instance.
        /// </returns>
        public override string ToString()
        {
            return (DayOfMonth != 0)
                ? string.Format("Occurs at {0} every {1} of {2}"
                    , TimeOfDay
                    , DayOfMonth
                    , Months)
                : string.Format("Occurs at {0} every {1} of {2}"
                    , TimeOfDay
                    , WeekDaysOfMonth
                    , Months);
        }

        /// <summary>
        /// Clones this instance.
        /// </summary>
        /// <returns>A copy of this instance.</returns>
        public override object Clone()
        {
            return (DayOfMonth != 0) 
                ? new MonthlyRecurrence(TimeOfDay, DayOfMonth, Months)
                : new MonthlyRecurrence(TimeOfDay, WeekDaysOfMonth, Months);
        }

        /// <summary>
        /// Gets the date and time of the next occurrence from <paramref name="now"/>.
        /// </summary>
        /// <param name="now">The current date and time assumed to be an universal time if not specified to avoid ambiguous dates.</param>
        /// <returns>The date and time of the next occurrence.</returns>
        protected override DateTime GetNextDateTime(DateTime now)
        {
            //assume universal time
            now = now.AsUniversalTime();

            //correct inexistent dates (ex: 27/03/2011 02:30:00 > 27/03/2011 03:30:30)
            now = now.ToUniversalTime().ToLocalTime();

            //consider today to maintain the specified timeOfDay across the daylight saving time period
            var today = (now.TimeOfDay < TimeOfDay) 
                ? now.Date.AddDays(-1)
                : now.Date;

            var nextDate = default(DateTime);
            if (DayOfMonth != 0)
            {
                nextDate = GetNextDateByDayOfMonth(today, Months, DayOfMonth);
            }
            else
            {
                nextDate = GetNextDateByWeekDayOfMonth(today, Months, WeekDaysOfMonth);
            }

            // add the specified timeOfDay
            return nextDate.Add(TimeOfDay);
        }

        internal static DateTime GetNextDateByDayOfMonth(DateTime value, RecurrentMonths months, int dayOfMonth)
        {
            var match = default(RecurrentMonths);

            do
            {
                value = value.AddDays(1);
                if (dayOfMonth > DateTime.DaysInMonth(value.Year, value.Month))
                {
                    value = value.AddDays(-value.Day + 1).AddMonths(1).AddDays(-1);
                    continue;
                } 

                if (value.Day != dayOfMonth)
                {
                    if (value.Day > dayOfMonth)
                        value = value.AddDays(DateTime.DaysInMonth(value.Year, value.Month) - value.Day).AddDays(dayOfMonth);
                    else
                        value = value.AddDays(dayOfMonth - value.Day);
                }

                match = FlagsEnum.PowerOfTwo<RecurrentMonths>(value.Month - 1);
            }
            while ((months & match) != match || value.Day != dayOfMonth);

            return value;
        }

        internal static DateTime GetNextDateByWeekDayOfMonth(DateTime value, RecurrentMonths months, RecurrentWeekDaysOfMonth weekDayOfMonth)
        {
            var firstOfMonth = value.AddDays(-value.Day + 1);

            //move to next month if necessary when any week days is requested
            if (weekDayOfMonth.AnyWeekDays)
            {
                var positionedDates = from positionedDate in DatesInPosition(firstOfMonth.Year, firstOfMonth.Month, weekDayOfMonth.Position)
                                      let dayOfWeek = FlagsEnum.PowerOfTwo<RecurrentWeekDays>(positionedDate.DayOfWeek)
                                      where (dayOfWeek & weekDayOfMonth.WeekDays) == dayOfWeek
                                      && positionedDate == value
                                      select positionedDate;
                
                if (positionedDates.Any())
                {
                    firstOfMonth = firstOfMonth.AddMonths(1);
                }
            }

            var nextDate = default(DateTime);
            do
            {
                var positionedDates = from positionedDate in DatesInPosition(firstOfMonth.Year, firstOfMonth.Month, weekDayOfMonth.Position)
                                      let dayOfWeek = FlagsEnum.PowerOfTwo<RecurrentWeekDays>(positionedDate.DayOfWeek)
                                      let month = FlagsEnum.PowerOfTwo<RecurrentMonths>(positionedDate.Month - 1)
                                      where (dayOfWeek & weekDayOfMonth.WeekDays) == dayOfWeek
                                      && (month & months) == month
                                      && positionedDate > value
                                      select positionedDate;

                //move to fourth position if necessary when all weekDays are requested in last position
                if (!weekDayOfMonth.AnyWeekDays
                    && weekDayOfMonth.Position == WeekOfMonthPosition.Last
                    && positionedDates.Count() > 1)
                {
                    //consider positionedDates discontinuity only for continuous weekDays
                    if (FlagsEnum.ContinuousFlagValues<RecurrentWeekDays>(weekDayOfMonth.WeekDays)
                        && positionedDates.First().AddDays(positionedDates.Count() - 1) != positionedDates.Last())
                    {
                        positionedDates = from positionedDate in DatesInPosition(firstOfMonth.Year, firstOfMonth.Month, WeekOfMonthPosition.Fourth)
                                          let dayOfWeek = FlagsEnum.PowerOfTwo<RecurrentWeekDays>(positionedDate.DayOfWeek)
                                          let month = FlagsEnum.PowerOfTwo<RecurrentMonths>(positionedDate.Month - 1)
                                          where (dayOfWeek & weekDayOfMonth.WeekDays) == dayOfWeek
                                          && (month & months) == month
                                          && positionedDate > value
                                          select positionedDate;
                    }
                }

                nextDate = (weekDayOfMonth.AnyWeekDays && weekDayOfMonth.Position == WeekOfMonthPosition.Last)
                    ? positionedDates.LastOrDefault()
                    : positionedDates.FirstOrDefault();
                firstOfMonth = firstOfMonth.AddMonths(1);
            }
            while (nextDate == default(DateTime));

            return nextDate;
        }

        internal static IEnumerable<DateTime> DatesInPosition(int year, int month, WeekOfMonthPosition position)
        {
            var day = default(int);
            var maxDay = default(int);
            
            if (position != WeekOfMonthPosition.Last)
            {
                day = 1 + 7 * FlagsEnum.PowerOfTwoExponent(position);
                maxDay = day + 6;
            }
            else
            {
                maxDay = DateTime.DaysInMonth(year, month);
                day = maxDay - 6;
            }

            while (day <= maxDay)
            {
                yield return new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Local);
                day += 1;
            }
        }

        #endregion

        #region Constructors

        private MonthlyRecurrence()
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="MonthlyRecurrence"/> class
        /// executed every <paramref name="dayOfMonth"/> of the specified <paramref name="months"/>
        /// at the specified <paramref name="timeOfDay"/>.
        /// </summary>
        /// <param name="timeOfDay">The time of day the recurrence is executed.</param>
        /// <param name="dayOfMonth">The recurrent day of month.</param>
        /// <param name="months">The recurrent months.</param>
        /// <exception cref="ArgumentOutOfRangeException">
        /// <paramref name="timeOfDay"/> is lower than 00:00:00 or greater than or equal to 1.00:00:00 -or-
        /// <paramref name="dayOfMonth"/> is not between 1 and the greatest days of the specified <paramref name="months"/>.
        /// </exception>
        public MonthlyRecurrence(TimeSpan timeOfDay, int dayOfMonth, RecurrentMonths months)
        {
            if (timeOfDay < TimeSpan.Zero || timeOfDay >= TimeSpan.FromDays(1))
                throw new ArgumentOutOfRangeException("timeOfDay");

            //determine upper bound of a leap year
            var upperBound = (from value in FlagsEnum.FlagValues<RecurrentMonths>(months)
                              select DateTime.DaysInMonth(2012, FlagsEnum.PowerOfTwoExponent(value) + 1)).Max();

            if (dayOfMonth < 1 || dayOfMonth > upperBound)
                throw new ArgumentOutOfRangeException("dayOfMonth"
                    , string.Format("Argument is not between 1 and the greatest days {0} of the specified months: {1}."
                    , upperBound
                    , months));

            TimeOfDay = timeOfDay;
            DayOfMonth = dayOfMonth;
            Months = months;
        }

         /// <summary>
        /// Initializes a new instance of the <see cref="MonthlyRecurrence"/> class
        /// executed every <paramref name="weekDayOfMonth"/> of the specified <paramref name="months"/>
        /// at the specified <paramref name="timeOfDay"/>.
        /// </summary>
        /// <param name="timeOfDay">The time of day the recurrence is executed.</param>
        /// <param name="weekDayOfMonth">The recurrent week day of month.</param>
        /// <param name="months">The recurrent months.</param>
        /// <exception cref="ArgumentOutOfRangeException">
        /// <paramref name="timeOfDay"/> is lower than 00:00:00 or greater than or equal to 1.00:00:00 
        /// </exception>
        public MonthlyRecurrence(TimeSpan timeOfDay, RecurrentWeekDaysOfMonth weekDayOfMonth, RecurrentMonths months)
        {
            if (timeOfDay < TimeSpan.Zero || timeOfDay >= TimeSpan.FromDays(1))
                throw new ArgumentOutOfRangeException("timeOfDay");
            
            TimeOfDay = timeOfDay;
            WeekDaysOfMonth = weekDayOfMonth;
            Months = months;
        }

        #endregion
    }
}